You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

182 lines
5.4 KiB

<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../utils/http/factory'
import { extractFrontMatterDesc, stripFrontMatter } from '../../utils/markdown-front-matter'
import { buildPublicCanonicalUrl } from '../../utils/public-canonical-url'
import { safeExternalHref } from '../../utils/safe-external-href'
import { usePublicProfileLayoutMode } from '../../composables/usePublicHomeLayout'
import ShowcaseLayout from '../../components/public-home/ShowcaseLayout.vue'
import ReaderLayout from '../../components/public-home/ReaderLayout.vue'
definePageMeta({
layout: 'public',
})
const route = useRoute()
const runtimeConfig = useRuntimeConfig()
const slug = computed(() => route.params.publicSlug as string)
const { mode } = usePublicProfileLayoutMode()
type PublicPostListItem = {
title?: string | null
excerpt?: string | null
slug?: string | null
publishedAt?: Date | string | null
tags?: string[]
}
type PublicTimelineItem = {
title?: string | null
bodyMarkdown?: string | null
}
type PublicRssListItem = {
title?: string | null
canonicalUrl?: string | null
canonical_url?: string | null
}
type ModulePayload<T> = {
items?: T[]
total?: number
}
type Payload = {
user: { publicSlug: string | null; nickname: string | null; avatar: string | null }
bio: { markdown: string } | null
links: { label: string; url?: string; href?: string; visibility: string; icon?: string }[]
modules?: {
posts?: ModulePayload<PublicPostListItem>
timeline?: ModulePayload<PublicTimelineItem>
reading?: ModulePayload<PublicRssListItem>
}
posts?: ModulePayload<PublicPostListItem>
timeline?: ModulePayload<PublicTimelineItem>
rssItems?: ModulePayload<PublicRssListItem>
}
type NormalizedModule<T> = {
items: T[]
total: number
}
function normalizeModule<T>(primary?: ModulePayload<T>, fallback?: ModulePayload<T>): NormalizedModule<T> {
const source = primary ?? fallback
return {
items: Array.isArray(source?.items) ? source.items : [],
total: typeof source?.total === 'number' ? source.total : 0,
}
}
function socialHref(link: { url?: string; href?: string }): string | undefined {
const value = link.url ?? link.href
return safeExternalHref(value, { allowMailto: true })
}
const { data, pending, error } = await useAsyncData(
() => `public-profile-${slug.value}`,
async () => {
const res = await $fetch<ApiResponse<Payload>>(`/api/public/profile/${encodeURIComponent(slug.value)}`)
return unwrapApiBody(res)
},
{ watch: [slug] },
)
const postsModule = computed(() => normalizeModule(data.value?.modules?.posts, data.value?.posts))
const timelineModule = computed(() => normalizeModule(data.value?.modules?.timeline, data.value?.timeline))
const readingModule = computed(() => normalizeModule(data.value?.modules?.reading, data.value?.rssItems))
const BIO_PREVIEW_MAX_CHARS = 140
const bioSummary = computed(() => {
const md = data.value?.bio?.markdown
if (!md?.trim()) {
return ''
}
const desc = extractFrontMatterDesc(md)
if (typeof desc === 'string' && desc.trim().length > 0) {
return desc.trim()
}
return stripFrontMatter(md).trim()
})
const bioPreviewText = computed(() => {
const plain = bioSummary.value.replace(/\s+/g, ' ').trim()
if (!plain) {
return ''
}
if (plain.length <= BIO_PREVIEW_MAX_CHARS) {
return plain
}
return `${plain.slice(0, BIO_PREVIEW_MAX_CHARS).trimEnd()}...`
})
const hasBioPreview = computed(() => bioPreviewText.value.length > 0)
const isDetailedMode = computed(() => mode.value === 'detailed')
const socialLinks = computed(() =>
(data.value?.links ?? [])
.map(link => ({ ...link, safeHref: socialHref(link) }))
.filter(link => typeof link.safeHref === 'string' && link.safeHref.length > 0) as Array<{
label: string
icon?: string
safeHref: string
}>,
)
const canonicalUrl = computed(() =>
buildPublicCanonicalUrl(runtimeConfig.public.siteUrl, route.fullPath),
)
useHead(() => ({
link: canonicalUrl.value
? [{ rel: 'canonical', href: canonicalUrl.value }]
: [],
}))
usePageTitle(() => {
const s = slug.value
const d = data.value
if (!d) {
return [`@${s}`, '主页']
}
const name = d.user.nickname || (d.user.publicSlug ? `@${d.user.publicSlug}` : '') || s
return [name, '主页']
})
</script>
<template>
<div v-if="pending && !data" class="text-muted py-10">
<UContainer>加载中…</UContainer>
</div>
<UContainer v-else-if="error && !data" class="py-10">
<UAlert color="error" title="无法加载主页" />
</UContainer>
<UContainer
v-else-if="data"
:class="isDetailedMode ? 'max-w-6xl py-8 lg:py-10' : 'max-w-2xl py-10 sm:py-14'"
>
<ReaderLayout
v-if="isDetailedMode"
:slug="slug"
:display-name="data.user.nickname || data.user.publicSlug || slug"
:public-slug="data.user.publicSlug"
:avatar="data.user.avatar"
:bio-preview-text="bioPreviewText"
:has-bio-preview="hasBioPreview"
:social-links="socialLinks"
/>
<ShowcaseLayout
v-else
:slug="slug"
:display-name="data.user.nickname || data.user.publicSlug || slug"
:public-slug="data.user.publicSlug"
:avatar="data.user.avatar"
:bio-preview-text="bioPreviewText"
:has-bio-preview="hasBioPreview"
:social-links="socialLinks"
:posts-module="postsModule"
:timeline-module="timelineModule"
:reading-module="readingModule"
/>
</UContainer>
</template>